Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit cd1ebd1ec1470b3968fb7aedfc69975b1b0be5f3


Parents : 250edf1
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-03T13:52:17-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-03T18:52:17Z

feat(takserver): surface mesh nodes to ATAK as CoT contacts (mesh-to-CoT) (#6554)

Changes

26 files changed, 1059 insertions(+), 91 deletions(-)


Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index b87add6771..464b2da140 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1609,6 +1609,8 @@ tak_server_enabled
tak_server_enabled_desc
tak_server_export_data_package_desc
tak_server_loading
+tak_server_mesh_to_cot
+tak_server_mesh_to_cot_desc
tak_server_section
tak_server_test_card_title
tak_server_test_idle

diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/tak/TakPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/tak/TakPrefsImpl.kt
index 28fc2250ed..ea614fd85c 100644
--- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/tak/TakPrefsImpl.kt
+++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/tak/TakPrefsImpl.kt
@@ -41,7 +41,15 @@ class TakPrefsImpl(private val dataStore: UiDataStore, dispatchers: CoroutineDis
scope.launch { dataStore.edit { prefs -> prefs[KEY_TAK_SERVER_ENABLED] = enabled } }
}
+ override val isMeshToCotEnabled: StateFlow<Boolean> =
+ dataStore.data.map { it[KEY_TAK_MESH_TO_COT] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
+
+ override fun setMeshToCotEnabled(enabled: Boolean) {
+ scope.launch { dataStore.edit { prefs -> prefs[KEY_TAK_MESH_TO_COT] = enabled } }
+ }
+
companion object {
val KEY_TAK_SERVER_ENABLED = booleanPreferencesKey("tak_server_enabled")
+ val KEY_TAK_MESH_TO_COT = booleanPreferencesKey("tak_mesh_to_cot")
}
}

diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt
index 6595f57014..496e2695c3 100644
--- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt
+++ b/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt
@@ -73,4 +73,27 @@ class TakPrefsTest {
takPrefs.setTakServerEnabled(false)
assertFalse(takPrefs.isTakServerEnabled.value)
}
+
+ @Test
+ fun `isMeshToCotEnabled defaults to false`() = testScope.runTest { assertFalse(takPrefs.isMeshToCotEnabled.value) }
+
+ @Test
+ fun `setting isMeshToCotEnabled updates preference`() = testScope.runTest {
+ takPrefs.setMeshToCotEnabled(true)
+ assertTrue(takPrefs.isMeshToCotEnabled.value)
+
+ takPrefs.setMeshToCotEnabled(false)
+ assertFalse(takPrefs.isMeshToCotEnabled.value)
+ }
+
+ @Test
+ fun `mesh to CoT is independent of the server toggle`() = testScope.runTest {
+ takPrefs.setMeshToCotEnabled(true)
+
+ takPrefs.setTakServerEnabled(true)
+ assertTrue(takPrefs.isMeshToCotEnabled.value)
+
+ takPrefs.setTakServerEnabled(false)
+ assertTrue(takPrefs.isMeshToCotEnabled.value)
+ }
}

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
index c2f26b3d2c..278e81cc64 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt
@@ -346,6 +346,14 @@ interface TakPrefs {
val isTakServerEnabled: StateFlow<Boolean>
fun setTakServerEnabled(enabled: Boolean)
+
+ /**
+ * Whether mesh nodes are synthesized into CoT contacts for connected TAK clients. Opt-in and default off; only
+ * takes effect while [isTakServerEnabled] is also true.
+ */
+ val isMeshToCotEnabled: StateFlow<Boolean>
+
+ fun setMeshToCotEnabled(enabled: Boolean)
}
/** Reactive interface for App Functions (system AI integration) preferences. */

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 4be92ff63b..15e3830b9f 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1654,6 +1654,8 @@
<string name="tak_server_enabled_desc">Starts a local TLS server on port 8089 for ATAK/iTAK connections</string>
<string name="tak_server_export_data_package_desc">Generate .zip for ATAK/iTAK to connect to this server</string>
<string name="tak_server_loading">…</string>
+ <string name="tak_server_mesh_to_cot">Mesh to CoT Converter</string>
+ <string name="tak_server_mesh_to_cot_desc">Show Meshtastic nodes on the ATAK/iTAK map as contacts</string>
<string name="tak_server_section">Server</string>
<string name="tak_server_test_card_title">TAK Mesh Test (Debug)</string>
<string name="tak_server_test_idle">Send all %1$d test fixtures to mesh</string>

diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
index a87b4e5aed..66a1a79653 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/MeshServiceOrchestratorTest.kt
@@ -48,6 +48,7 @@ import org.meshtastic.core.repository.RadioSessionContext
import org.meshtastic.core.repository.ReceivedRadioFrame
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TakPrefs
+import org.meshtastic.core.takserver.MeshToCotBroadcaster
import org.meshtastic.core.takserver.TAKMeshIntegration
import org.meshtastic.core.takserver.TAKServerManager
import org.meshtastic.proto.FromRadio
@@ -85,6 +86,7 @@ class MeshServiceOrchestratorTest {
private val dispatchers = CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher)
/** Stubs the shared flow dependencies used by every test and returns an orchestrator. */
+ @OptIn(ExperimentalCoroutinesApi::class)
private fun createOrchestrator(
receivedData: MutableSharedFlow<ReceivedRadioFrame> = MutableSharedFlow(),
connectionError: MutableSharedFlow<String> = MutableSharedFlow(),
@@ -113,10 +115,15 @@ class MeshServiceOrchestratorTest {
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig())
every { takPrefs.isTakServerEnabled } returns takEnabledFlow
+ every { takPrefs.isMeshToCotEnabled } returns MutableStateFlow(false)
every { takServerManager.isRunning } returns takRunningFlow
every { takServerManager.inboundMessages } returns MutableSharedFlow()
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ // Deliberately its own dispatcher, not the class-level testDispatcher: the broadcaster's
+ // scheduler doesn't need to be the same one driving this test, and a distinct name keeps
+ // that from reading as though the two are linked.
+ val broadcasterTestDispatcher = UnconfinedTestDispatcher()
val takMeshIntegration =
TAKMeshIntegration(
takServerManager = takServerManager,
@@ -124,6 +131,18 @@ class MeshServiceOrchestratorTest {
serviceRepository = serviceRepository,
meshConfigHandler = meshConfigHandler,
nodeRepository = nodeRepository,
+ meshToCotBroadcaster =
+ MeshToCotBroadcaster(
+ takServerManager = takServerManager,
+ nodeRepository = nodeRepository,
+ takPrefs = takPrefs,
+ dispatchers =
+ CoroutineDispatchers(
+ io = broadcasterTestDispatcher,
+ main = broadcasterTestDispatcher,
+ default = broadcasterTestDispatcher,
+ ),
+ ),
)
return MeshServiceOrchestrator(

diff --git a/core/takserver/README.md b/core/takserver/README.md
index f06a5a191a..669bc167ad 100644
--- a/core/takserver/README.md
+++ b/core/takserver/README.md
@@ -132,7 +132,32 @@ core:takserver
## Local TAK Server Feature
-The Local TAK Server can be enabled from the app's Settings screen. When running, ATAK/iTAK clients on the same network can connect to `<device-ip>:8089` and their position reports are automatically bridged onto the mesh. Mesh node positions are broadcast to all connected TAK clients in real time.
+The Local TAK Server can be enabled from the app's Settings screen. When running, ATAK/iTAK clients on the same network can connect to `<device-ip>:8089` and their position reports are automatically bridged onto the mesh. CoT arriving from the mesh on ports 72/78 is forwarded to every connected TAK client.
+
+### Mesh to CoT (node contacts)
+
+Separately opt-in (`TakPrefs.isMeshToCotEnabled`, default off, shown as "Mesh to CoT Converter" under the server toggle). When enabled alongside the server, `MeshToCotBroadcaster` synthesizes a CoT contact for each node in the node database so regular Meshtastic nodes appear on the ATAK map without the legacy Meshtastic TAK Plugin β€” which cannot work at all since the AIDL API was removed in app 2.8.0.
+
+Nodes qualify when they have identified themselves, were heard inside the online window (2 h), and hold a valid position; the local node is excluded because ATAK renders it as self.
+
+Output is aligned against Meshtastic-Apple's `TAKMeshtasticBridge.createCoTFromNode` (verified by reading that source, not inferred) so the same physical node presents identically on both platforms:
+
+| Field | Value | Notes |
+| --- | --- | --- |
+| `uid` | `MESHTASTIC-%08X` | **Upper-case hex is load-bearing.** ATAK keys contacts by UID and compares case-sensitively; lower-casing it makes an Android-bridged node a *separate* contact from the same iOS-bridged node, so the mesh appears duplicated when both phones bridge one TAK network. |
+| `callsign` | `SHORT - Long Name` | Falls back through whichever names are populated. |
+| team / role | `Green` / `Team Member` | Remote nodes never report a TAK team. |
+| stale | 15 min | Paired with the 5-min refresh below. |
+| `remarks` | `Battery … \| Voltage … \| Chan Util … \| Air Util Tx … \| RSSI … \| SNR …` | Labels, order, and precision match Apple (voltage at two decimals, the rest at one). |
+
+Two deliberate divergences from Apple, both in `remarks`:
+
+- **Zero is reported, not suppressed.** Apple gates each field on a non-zero value (`if voltage > 0`, `if rssi != 0`, …) and substitutes 100% for an unreported battery. Here, absence is detected via nullability and the SNR/RSSI sentinels instead β€” 0 dB SNR and 0 dBm RSSI are real measurements, and 0% battery is precisely the reading an operator needs to see rather than have hidden.
+- **`Air Util Tx` is additive** β€” no Apple counterpart.
+
+`Node.validPosition` (the repo-wide helper) also requires *both* coordinates non-zero and in range, where Apple accepts either being non-zero; a node sitting exactly on the equator or prime meridian is therefore dropped here. Kept for consistency with every other position filter in the codebase.
+
+Nothing on this path crosses the mesh, so none of it is subject to the LoRa MTU or the TAKPacket wire format. Three behaviours are load-bearing: broadcasts are suppressed while no client is attached (they would otherwise evict real mesh CoT from the 50-entry offline queue), a connecting client triggers a full replay, and every node is re-sent periodically so stationary markers do not expire at `MESH_NODE_STALE_MINUTES`.
## TAKPacket-SDK consumer & version-bump playbook

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTConversion.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTConversion.kt
index 213fdcba2f..319154974c 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTConversion.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/CoTConversion.kt
@@ -25,6 +25,8 @@ fun org.meshtastic.proto.Position.toCoTMessage(
team: String = DEFAULT_TAK_TEAM_NAME,
role: String = DEFAULT_TAK_ROLE_NAME,
battery: Int = DEFAULT_TAK_BATTERY,
+ staleMinutes: Int = DEFAULT_TAK_STALE_MINUTES,
+ remarks: String? = null,
): CoTMessage {
val lat = (latitude_i ?: 0).toDouble() / TAK_COORDINATE_SCALE
val lon = (longitude_i ?: 0).toDouble() / TAK_COORDINATE_SCALE
@@ -43,7 +45,8 @@ fun org.meshtastic.proto.Position.toCoTMessage(
team = team,
role = role,
battery = battery,
- staleMinutes = DEFAULT_TAK_STALE_MINUTES,
+ staleMinutes = staleMinutes,
+ remarks = remarks,
)
}
@@ -52,21 +55,34 @@ fun org.meshtastic.proto.User.toCoTMessage(
team: String = DEFAULT_TAK_TEAM_NAME,
role: String = DEFAULT_TAK_ROLE_NAME,
battery: Int = DEFAULT_TAK_BATTERY,
+ uid: String = id,
+ callsign: String = toTakCallsign(),
+ staleMinutes: Int = DEFAULT_TAK_STALE_MINUTES,
+ remarks: String? = null,
): CoTMessage = if (position != null) {
- position.toCoTMessage(uid = id, callsign = toTakCallsign(), team = team, role = role, battery = battery)
+ position.toCoTMessage(
+ uid = uid,
+ callsign = callsign,
+ team = team,
+ role = role,
+ battery = battery,
+ staleMinutes = staleMinutes,
+ remarks = remarks,
+ )
} else {
val now = Clock.System.now()
CoTMessage(
- uid = id,
- type = "a-f-G-U-C",
+ uid = uid,
+ type = DEFAULT_PLI_COT_TYPE,
time = now,
start = now,
- stale = now + DEFAULT_TAK_STALE_MINUTES.minutes,
+ stale = now + staleMinutes.minutes,
how = "m-g",
latitude = 0.0,
longitude = 0.0,
- contact = CoTContact(callsign = toTakCallsign(), endpoint = DEFAULT_TAK_ENDPOINT),
+ contact = CoTContact(callsign = callsign, endpoint = DEFAULT_TAK_ENDPOINT),
group = CoTGroup(name = team, role = role),
status = CoTStatus(battery = battery),
+ remarks = remarks,
)
}

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversion.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversion.kt
new file mode 100644
index 0000000000..5f420b09a7
--- /dev/null
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversion.kt
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.takserver
+
+import org.meshtastic.core.common.util.NumberFormatter
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.NodeAddress
+import org.meshtastic.core.model.util.onlineTimeThreshold
+
+// Converts entries of the Meshtastic node database into CoT contacts for connected TAK clients.
+//
+// This is the local mesh -> ATAK visualization path. Nothing produced here crosses the mesh, so none of it is subject
+// to the LoRa MTU or the TAKPacket wire format β€” that is TAKMeshIntegration's concern.
+
+/**
+ * True when [this] node should be presented to TAK clients as a contact.
+ *
+ * Mirrors Meshtastic-Apple's filter: the node must have identified itself, been heard recently, and hold a usable
+ * position. [ourNodeNum] is excluded because ATAK already renders the operator's own position as self; when it is null
+ * the local node number is not yet known and nothing is excluded.
+ */
+internal fun Node.isEligibleForCot(ourNodeNum: Int?, lastHeardThreshold: Int = onlineTimeThreshold()): Boolean =
+ num != ourNodeNum && !isUnknownUser && lastHeard > lastHeardThreshold && validPosition != null
+
+/**
+ * The stable CoT UID for [this] node, e.g. `MESHTASTIC-A1B2C3D4`.
+ *
+ * ATAK identifies a contact by UID, so this must stay stable across broadcasts or every refresh spawns a duplicate
+ * marker. Derived from the node number rather than the user record because [Node.user] can be empty or change.
+ *
+ * Upper-case hex is load-bearing, not cosmetic β€” see [MESH_NODE_UID_PREFIX]. Deliberately not built from
+ * [NodeAddress.numToDefaultId], which formats `!%08x` in lower case for display.
+ */
+internal fun Node.cotUid(): String =
+ MESH_NODE_UID_PREFIX + num.toUInt().toString(TAK_HEX_RADIX).uppercase().padStart(MESH_NODE_UID_HEX_WIDTH, '0')
+
+/**
+ * The ATAK callsign for [this] node β€” `"SHORT - Long Name"`, matching Apple. Falls back through the names that are
+ * actually populated so a partially-identified node never renders as a blank contact.
+ */
+internal fun Node.cotCallsign(): String {
+ val short = user.short_name.trim()
+ val long = user.long_name.trim()
+ return when {
+ short.isNotEmpty() && long.isNotEmpty() -> "$short - $long"
+ short.isNotEmpty() -> short
+ long.isNotEmpty() -> long
+ else -> NodeAddress.numToDefaultId(num)
+ }
+}
+
+/**
+ * Telemetry summary carried in the CoT `<remarks>` element.
+ *
+ * Field labels, order, and precision match Apple's `createCoTFromNode` so an operator reading a contact's remarks sees
+ * the same text regardless of which phone bridged it.
+ *
+ * **Deliberate divergence:** Apple suppresses a field when its value is zero (`if voltage > 0`, `if rssi != 0`, …).
+ * This repo treats zero as a real reading β€” 0 dB SNR and 0 dBm RSSI are genuine measurements, and 0% battery is exactly
+ * the value an operator most needs to see β€” so absence is detected via nullability and the SNR/RSSI sentinels
+ * ([Node.snrOrNull] / [Node.rssiOrNull]) instead. Apple also substitutes 100% for an unreported battery; omitting it is
+ * preferred over reporting a figure the node never sent. `Air Util Tx` has no Apple counterpart and is additive.
+ */
+internal fun Node.cotRemarks(): String? {
+ val parts = buildList {
+ batteryLevel?.let { add("Battery: $it%") }
+ voltage?.let { add("Voltage: ${NumberFormatter.format(it, VOLTAGE_DECIMALS)}V") }
+ deviceMetrics.channel_utilization?.let { add("Chan Util: ${NumberFormatter.format(it, 1)}%") }
+ deviceMetrics.air_util_tx?.let { add("Air Util Tx: ${NumberFormatter.format(it, 1)}%") }
+ rssiOrNull?.let { add("RSSI: $it dBm") }
+ snrOrNull?.let { add("SNR: ${NumberFormatter.format(it, 1)} dB") }
+ }
+ return parts.joinToString(" | ").ifEmpty { null }
+}
+
+/** Apple prints voltage with two decimals (`%.2f`) where every other telemetry field uses one. */
+private const val VOLTAGE_DECIMALS = 2
+
+/**
+ * Build the CoT event representing [this] node.
+ *
+ * Callers are expected to have filtered with [isEligibleForCot] first; a node without a valid position still converts,
+ * but yields a 0/0 point that ATAK will place in the Gulf of Guinea.
+ */
+internal fun Node.toCoTMessage(): CoTMessage = user.toCoTMessage(
+ position = validPosition,
+ team = MESH_NODE_TAK_TEAM,
+ role = DEFAULT_TAK_ROLE_NAME,
+ battery = batteryLevel ?: DEFAULT_TAK_BATTERY,
+ uid = cotUid(),
+ callsign = cotCallsign(),
+ staleMinutes = MESH_NODE_STALE_MINUTES,
+ remarks = cotRemarks(),
+)

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/MeshToCotBroadcaster.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/MeshToCotBroadcaster.kt
new file mode 100644
index 0000000000..c78c9ec55e
--- /dev/null
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/MeshToCotBroadcaster.kt
@@ -0,0 +1,159 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.takserver
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.TakPrefs
+import kotlin.concurrent.Volatile
+import kotlin.concurrent.atomics.AtomicBoolean
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
+import kotlin.time.Instant
+
+/**
+ * Publishes Meshtastic node-database entries to connected TAK clients as CoT contacts.
+ *
+ * This is the mesh -> ATAK visualization path, and it is entirely local: synthesized node CoT travels app -> TAK client
+ * and never crosses the mesh. Mesh-originated TAKPacket traffic is [TAKMeshIntegration]'s job; the two are independent.
+ *
+ * Opt-in via [TakPrefs.isMeshToCotEnabled] and additionally gated on the TAK server running, because the owning
+ * [TAKMeshIntegration] is itself only started while the server is enabled.
+ */
+@OptIn(ExperimentalAtomicApi::class)
+class MeshToCotBroadcaster(
+ private val takServerManager: TAKServerManager,
+ private val nodeRepository: NodeRepository,
+ private val takPrefs: TakPrefs,
+ private val dispatchers: CoroutineDispatchers,
+) {
+ private val isRunning = AtomicBoolean(false)
+
+ @Volatile private var job: Job? = null
+
+ // Last CoT sent per node number, time-normalized (see normalizeForDedup) so an unchanged node
+ // is not re-sent on every unrelated nodeDB emission. Guarded by sentMutex.
+ private val lastSent = mutableMapOf<Int, CoTMessage>()
+ private val sentMutex = Mutex()
+
+ fun start(scope: CoroutineScope) {
+ // CAS, not a job-null check: two concurrent start() calls must not both launch, and a
+ // dead job left by a cancelled scope must not block every future start().
+ if (!isRunning.compareAndSet(expectedValue = false, newValue = true)) return
+ job =
+ scope.launch(dispatchers.default) {
+ try {
+ takPrefs.isMeshToCotEnabled.collectLatest { enabled ->
+ if (!enabled) return@collectLatest
+ Logger.i { "Mesh-to-CoT enabled β€” publishing node contacts to TAK clients" }
+ runEnabled()
+ }
+ } finally {
+ // Owning scope cancelled without stop(): release the guard so a later start()
+ // on a fresh scope isn't refused forever.
+ isRunning.store(false)
+ }
+ }
+ }
+
+ fun stop() {
+ if (!isRunning.compareAndSet(expectedValue = true, newValue = false)) return
+ // lastSent is deliberately NOT cleared here: cancel() doesn't join, so a still-finishing
+ // publish() on another thread may hold sentMutex, and clearing unsynchronized would race
+ // it. runEnabled() drops the state on the next enable instead.
+ job?.cancel()
+ job = null
+ Logger.i { "Mesh-to-CoT stopped" }
+ }
+
+ private suspend fun runEnabled() = coroutineScope {
+ // Fresh dedup state per enable cycle, so nothing carried over from a previous run (or a
+ // previous device) suppresses the initial publish.
+ clearSent()
+
+ // A newly attached client has none of our prior broadcasts, so drop the dedup state
+ // and replay every eligible node. Uses the live snapshot rather than the nodeDBbyNum
+ // cache, which can briefly hold the previous transport's map after a device switch.
+ launch {
+ takServerManager.clientConnected.collect {
+ clearSent()
+ publish(nodeRepository.getNodeDbSnapshot().values, reason = "client connected")
+ }
+ }
+
+ // Stationary nodes never change, so dedup alone would let their ATAK markers expire at
+ // MESH_NODE_STALE_MINUTES. Re-send everything well inside that window.
+ launch {
+ while (true) {
+ delay(MESH_TO_COT_REFRESH_INTERVAL_MS)
+ clearSent()
+ publish(nodeRepository.getNodeDbSnapshot().values, reason = "periodic refresh")
+ }
+ }
+
+ launch { nodeRepository.nodeDBbyNum.collect { publish(it.values, reason = "node update") } }
+ }
+
+ private suspend fun publish(nodes: Collection<Node>, reason: String) {
+ // Without a connected client every broadcast() would land in TAKServerManager's 50-entry
+ // offline queue and evict genuine mesh CoT. Nothing is lost by skipping: connecting a
+ // client triggers a full replay. Re-checked per node because a client can disconnect
+ // mid-replay, and the inter-message spacing makes a large replay take whole seconds.
+ val ourNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
+ val eligible = nodes.filter { it.isEligibleForCot(ourNodeNum) }
+ var sent = 0
+ for (node in eligible) {
+ if (takServerManager.connectionCount.value <= 0) return
+ val cot = node.toCoTMessage()
+ if (!admit(node.num, cot)) continue
+ takServerManager.broadcast(cot)
+ sent++
+ delay(MESH_TO_COT_BROADCAST_SPACING_MS)
+ }
+ if (sent > 0) {
+ Logger.i { "Mesh-to-CoT: sent $sent of ${eligible.size} eligible node(s) to TAK clients ($reason)" }
+ }
+ }
+
+ /** Records [cot] as the latest for [nodeNum], returning false when an identical event was already sent. */
+ private suspend fun admit(nodeNum: Int, cot: CoTMessage): Boolean = sentMutex.withLock {
+ val normalized = normalizeForDedup(cot)
+ if (lastSent[nodeNum] == normalized) return false
+ lastSent[nodeNum] = normalized
+ true
+ }
+
+ private suspend fun clearSent() = sentMutex.withLock { lastSent.clear() }
+
+ private companion object {
+ /**
+ * Strips the timestamps so two events describing the same node state compare equal. Every other CoT field
+ * participates, so any change to position, callsign, battery, or telemetry re-broadcasts.
+ */
+ fun normalizeForDedup(cot: CoTMessage): CoTMessage =
+ cot.copy(time = Instant.DISTANT_PAST, start = Instant.DISTANT_PAST, stale = Instant.DISTANT_PAST)
+ }
+}

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt
index 3ac0226d7f..1a823c5060 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKDefaults.kt
@@ -72,6 +72,44 @@ internal const val MAX_TAK_WIRE_PAYLOAD_BYTES = 225
/** Default CoT type for PLI (Position Location Information) β€” friendly ground unit. */
internal const val DEFAULT_PLI_COT_TYPE = "a-f-G-U-C"
+// ── Mesh-to-CoT (synthesized node contacts) ─────────────────────────────────
+// These values mirror Meshtastic-Apple's TAKMeshtasticBridge.createCoTFromNode so a
+// mesh node presents identically in ATAK whether the operator runs Android or iOS.
+// Changing any of them makes the same physical node appear as a different contact
+// depending on which phone is bridging.
+
+/**
+ * UID prefix for synthesized node CoT, e.g. `MESHTASTIC-A1B2C3D4`.
+ *
+ * The hex suffix is **upper-case, zero-padded to [MESH_NODE_UID_HEX_WIDTH]**, matching Apple's `String(format:
+ * "MESHTASTIC-%08X", node.num)`. ATAK keys a contact by UID and compares case-sensitively, so lower-casing this makes
+ * an Android-bridged node a *different* contact from the same iOS-bridged node β€” the mesh appears duplicated whenever
+ * both phones bridge into one TAK network.
+ */
+internal const val MESH_NODE_UID_PREFIX = "MESHTASTIC-"
+
+/** Hex digits in a node number, zero-padded β€” `%08X` on the Apple side. */
+internal const val MESH_NODE_UID_HEX_WIDTH = 8
+
+/** Team colour for synthesized node CoT. Remote nodes never report a TAK team, so Apple's fixed Green is used. */
+internal const val MESH_NODE_TAK_TEAM = "Green"
+
+/**
+ * Stale TTL for synthesized node CoT. Longer than [DEFAULT_TAK_STALE_MINUTES] because a mesh node's position updates
+ * far less often than a live TAK client's. [MESH_TO_COT_REFRESH_INTERVAL_MS] must stay comfortably below this or
+ * stationary nodes expire out of ATAK between refreshes.
+ */
+internal const val MESH_NODE_STALE_MINUTES = 15
+
+/**
+ * How often every eligible node is re-broadcast even when nothing about it changed. Keeps stationary nodes alive in
+ * ATAK, whose markers would otherwise expire at [MESH_NODE_STALE_MINUTES].
+ */
+internal const val MESH_TO_COT_REFRESH_INTERVAL_MS = 5L * 60L * 1_000L
+
+/** Spacing between consecutive node broadcasts so a full replay doesn't flood the TAK client. Matches Apple's 10ms. */
+internal const val MESH_TO_COT_BROADCAST_SPACING_MS = 10L
+
/**
* Max characters of raw CoT XML we'll write to logcat when dropping an oversized packet. ATAK can emit events several
* KB long; logging the whole thing floods logcat and buries the signal. 1024 chars is enough to see the event type,

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
index 225397a45c..b0ae5c5a95 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt
@@ -70,6 +70,7 @@ class TAKMeshIntegration(
private val serviceRepository: ServiceRepository,
private val meshConfigHandler: MeshConfigHandler,
private val nodeRepository: NodeRepository,
+ private val meshToCotBroadcaster: MeshToCotBroadcaster,
) {
private val isRunning = AtomicBoolean(false)
@@ -142,6 +143,9 @@ class TAKMeshIntegration(
)
jobs = newJobs
+ // Node -> CoT contacts. Self-gates on its own opt-in pref; starting it here means it can
+ // only ever run while the TAK server is enabled.
+ meshToCotBroadcaster.start(scope)
val fw = nodeRepository.myNodeInfo.value?.firmwareVersion
val proto = if (Capabilities(fw).supportsTakV2) "v2 (port 78, zstd)" else "v1 (port 72, legacy)"
Logger.i { "TAK Mesh Integration started β€” firmware=$fw, outbound=$proto" }
@@ -152,6 +156,7 @@ class TAKMeshIntegration(
val toCancel = jobs
jobs = emptyList()
toCancel.forEach(Job::cancel)
+ meshToCotBroadcaster.stop()
takServerManager.stop()
Logger.i { "TAK Mesh Integration stopped" }
}

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKModels.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKModels.kt
index b66aa76eeb..17e92b3596 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKModels.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKModels.kt
@@ -73,6 +73,7 @@ data class CoTMessage(
role: String = DEFAULT_TAK_ROLE_NAME,
battery: Int = DEFAULT_TAK_BATTERY,
staleMinutes: Int = DEFAULT_TAK_STALE_MINUTES,
+ remarks: String? = null,
): CoTMessage {
val now = Clock.System.now()
return CoTMessage(
@@ -91,6 +92,7 @@ data class CoTMessage(
group = CoTGroup(name = team, role = role),
status = CoTStatus(battery = battery),
track = CoTTrack(speed = speed, course = course),
+ remarks = remarks,
)
}

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt
index 6f13f067e4..c5391ce2c3 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKServerManager.kt
@@ -38,6 +38,14 @@ interface TAKServerManager {
val connectionCount: StateFlow<Int>
val inboundMessages: SharedFlow<InboundCoTMessage>
+ /**
+ * Emits once each time a TAK client connects.
+ *
+ * [TAKServer.onClientConnected] is a single callback slot already owned by the offline-queue drain, so consumers
+ * that need the connect event observe it here instead of replacing that callback.
+ */
+ val clientConnected: SharedFlow<Unit>
+
/** Start the TAK server using [scope]. Port is fixed at [TAKServer] construction time. */
fun start(scope: CoroutineScope)
@@ -62,6 +70,9 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe
private val _inboundMessages = MutableSharedFlow<InboundCoTMessage>(extraBufferCapacity = 64)
override val inboundMessages: SharedFlow<InboundCoTMessage> = _inboundMessages.asSharedFlow()
+ private val _clientConnected = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
+ override val clientConnected: SharedFlow<Unit> = _clientConnected.asSharedFlow()
+
// Offline message queue β€” buffers mesh-originated CoT messages when no TAK
// clients are connected, then drains them when a client reconnects. Entries
// expire after OFFLINE_QUEUE_TTL to avoid delivering stale situational data.
@@ -94,7 +105,10 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe
Logger.w { "TAK inbound message buffer full; dropping message from ${clientInfo?.id}" }
}
}
- takServer.onClientConnected = { drainOfflineQueue() }
+ takServer.onClientConnected = {
+ drainOfflineQueue()
+ _clientConnected.tryEmit(Unit)
+ }
val result = takServer.start(scope)
if (result.isSuccess) {
@@ -102,8 +116,9 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe
Logger.i { "TAK Server started" }
} else {
Logger.e(result.exceptionOrNull()) { "Failed to start TAK Server" }
- // Clear onMessage if start failed so we don't hold a reference unnecessarily
+ // Clear both callbacks if start failed so we don't hold a reference unnecessarily
takServer.onMessage = null
+ takServer.onClientConnected = null
}
}
}
@@ -116,6 +131,7 @@ internal class TAKServerManagerImpl(private val takServer: TAKServer) : TAKServe
_isRunning.value = false
scope = null
takServer.onMessage = null
+ takServer.onClientConnected = null
takServer.stop()
Logger.i { "TAK Server stopped" }
}

diff --git a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt
index 67003de8ca..bc93610b4a 100644
--- a/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt
+++ b/core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/di/CoreTakServerModule.kt
@@ -23,6 +23,8 @@ import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.TakPrefs
+import org.meshtastic.core.takserver.MeshToCotBroadcaster
import org.meshtastic.core.takserver.TAKMeshIntegration
import org.meshtastic.core.takserver.TAKServer
import org.meshtastic.core.takserver.TAKServerManager
@@ -36,6 +38,14 @@ class CoreTakServerModule {
@Single fun provideTAKServerManager(takServer: TAKServer): TAKServerManager = TAKServerManagerImpl(takServer)
+ @Single
+ fun provideMeshToCotBroadcaster(
+ takServerManager: TAKServerManager,
+ nodeRepository: NodeRepository,
+ takPrefs: TakPrefs,
+ dispatchers: CoroutineDispatchers,
+ ): MeshToCotBroadcaster = MeshToCotBroadcaster(takServerManager, nodeRepository, takPrefs, dispatchers)
+
@Single
fun provideTAKMeshIntegration(
takServerManager: TAKServerManager,
@@ -43,6 +53,13 @@ class CoreTakServerModule {
serviceRepository: ServiceRepository,
meshConfigHandler: MeshConfigHandler,
nodeRepository: NodeRepository,
- ): TAKMeshIntegration =
- TAKMeshIntegration(takServerManager, commandSender, serviceRepository, meshConfigHandler, nodeRepository)
+ meshToCotBroadcaster: MeshToCotBroadcaster,
+ ): TAKMeshIntegration = TAKMeshIntegration(
+ takServerManager,
+ commandSender,
+ serviceRepository,
+ meshConfigHandler,
+ nodeRepository,
+ meshToCotBroadcaster,
+ )
}

diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/FakeTAKServerManager.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/FakeTAKServerManager.kt
new file mode 100644
index 0000000000..f4a62f5ae3
--- /dev/null
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/FakeTAKServerManager.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.takserver
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Shared [TAKServerManager] fake for this module's commonTest. Not in `:core:testing` β€” that module's
+ * `build.gradle.kts` documents an explicit boundary ("Heavy modules ... should depend on core:testing, not vice versa")
+ * that a `:core:takserver`-specific fake would violate.
+ *
+ * `isRunning` defaults to `false` and toggles via [start]/[stop] like the real [TAKServerManagerImpl]; callers that
+ * only exercise the connected-client / broadcast surface (not the start/stop lifecycle) can ignore it.
+ */
+internal class FakeTAKServerManager : TAKServerManager {
+ private val _isRunning = MutableStateFlow(false)
+ override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
+
+ val connections = MutableStateFlow(0)
+ override val connectionCount: StateFlow<Int> = connections
+
+ private val _inboundMessages = MutableSharedFlow<InboundCoTMessage>(extraBufferCapacity = 64)
+ override val inboundMessages: SharedFlow<InboundCoTMessage> = _inboundMessages.asSharedFlow()
+
+ private val _clientConnected = MutableSharedFlow<Unit>(extraBufferCapacity = 8)
+ override val clientConnected: SharedFlow<Unit> = _clientConnected.asSharedFlow()
+
+ val broadcasts = mutableListOf<CoTMessage>()
+ val rawBroadcasts = mutableListOf<String>()
+ var startCount = 0
+ var stopped = false
+
+ override fun start(scope: CoroutineScope) {
+ startCount++
+ _isRunning.value = true
+ }
+
+ override fun stop() {
+ stopped = true
+ _isRunning.value = false
+ }
+
+ override fun broadcast(cotMessage: CoTMessage) {
+ broadcasts.add(cotMessage)
+ }
+
+ override fun broadcastRawXml(xml: String) {
+ rawBroadcasts.add(xml)
+ }
+
+ suspend fun emitInbound(cotMessage: CoTMessage, clientInfo: TAKClientInfo? = null) {
+ _inboundMessages.emit(InboundCoTMessage(cotMessage, clientInfo))
+ }
+
+ suspend fun emitClientConnected() = _clientConnected.emit(Unit)
+}

diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversionTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversionTest.kt
new file mode 100644
index 0000000000..5ffef6aaba
--- /dev/null
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshNodeCoTConversionTest.kt
@@ -0,0 +1,198 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.takserver
+
+import org.meshtastic.core.model.Node
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.minutes
+
+class MeshNodeCoTConversionTest {
+
+ @Test
+ fun `eligible node passes the filter`() {
+ assertTrue(meshNode().isEligibleForCot(ourNodeNum = OTHER_NODE_NUM, lastHeardThreshold = STALE_THRESHOLD))
+ }
+
+ @Test
+ fun `own node is excluded`() {
+ assertFalse(meshNode().isEligibleForCot(ourNodeNum = NODE_NUM, lastHeardThreshold = STALE_THRESHOLD))
+ }
+
+ @Test
+ fun `node without user info is excluded`() {
+ val node = meshNode(hwModel = org.meshtastic.proto.HardwareModel.UNSET)
+ assertFalse(node.isEligibleForCot(ourNodeNum = OTHER_NODE_NUM, lastHeardThreshold = STALE_THRESHOLD))
+ }
+
+ @Test
+ fun `node heard outside the window is excluded`() {
+ val node = meshNode(lastHeard = STALE_THRESHOLD - 1)
+ assertFalse(node.isEligibleForCot(ourNodeNum = OTHER_NODE_NUM, lastHeardThreshold = STALE_THRESHOLD))
+ }
+
+ @Test
+ fun `node without a valid position is excluded`() {
+ val node = meshNode(latitudeI = 0, longitudeI = 0)
+ assertFalse(node.isEligibleForCot(ourNodeNum = OTHER_NODE_NUM, lastHeardThreshold = STALE_THRESHOLD))
+ }
+
+ @Test
+ fun `uid is derived from the node number and stays stable`() {
+ // ATAK keys a contact by uid; a uid that drifts spawns duplicate markers.
+ assertEquals("MESHTASTIC-A1B2C3D4", meshNode().cotUid())
+ assertEquals(meshNode().cotUid(), meshNode(shortName = "DIFF", longName = "Renamed").cotUid())
+ }
+
+ @Test
+ fun `uid hex is upper-case and zero-padded to match Apple`() {
+ // Apple formats "MESHTASTIC-%08X". ATAK compares uids case-sensitively, so lower-case hex
+ // would make an Android-bridged node a separate contact from the same iOS-bridged node.
+ val uid = meshNode().cotUid()
+ assertEquals(uid.uppercase(), uid)
+
+ // Low node number must keep its leading zeros rather than collapsing to "MESHTASTIC-2A".
+ val lowNum = Node(num = 0x2a, user = user(), position = position(), lastHeard = RECENT_LAST_HEARD)
+ assertEquals("MESHTASTIC-0000002A", lowNum.cotUid())
+ }
+
+ @Test
+ fun `callsign is short and long name joined`() {
+ assertEquals("WOLF - Wolf Ridge Relay", meshNode().cotCallsign())
+ }
+
+ @Test
+ fun `callsign falls back when a name is missing`() {
+ assertEquals("WOLF", meshNode(longName = "").cotCallsign())
+ assertEquals("Wolf Ridge Relay", meshNode(shortName = "").cotCallsign())
+ assertEquals("!a1b2c3d4", meshNode(shortName = "", longName = "").cotCallsign())
+ }
+
+ @Test
+ fun `remarks carry the reported telemetry`() {
+ // Labels, order, and precision match Apple's createCoTFromNode (voltage at two decimals,
+ // everything else at one) so remarks read identically whichever phone bridged the node.
+ val remarks = meshNode(voltage = 3.95f).cotRemarks()
+ assertEquals(
+ "Battery: 76% | Voltage: 3.95V | Chan Util: 12.5% | Air Util Tx: 4.2% | RSSI: -92 dBm | SNR: 8.5 dB",
+ remarks,
+ )
+ }
+
+ @Test
+ fun `remarks omit unreported telemetry rather than reporting zero`() {
+ // A missing reading must not render as "0" β€” zero is a real value for every one of these.
+ val node = Node(num = NODE_NUM, user = user(), position = position(), lastHeard = RECENT_LAST_HEARD)
+ assertNull(node.cotRemarks())
+ }
+
+ @Test
+ fun `zero is preserved as a real telemetry reading`() {
+ val node = meshNode(batteryLevel = 0, voltage = 0f, channelUtilization = 0f, airUtilTx = 0f, snr = 0f, rssi = 0)
+ // Apple suppresses each of these at zero; this repo reports them, since 0 dB SNR / 0 dBm
+ // RSSI are real measurements and 0% battery is the value an operator most needs to see.
+ val remarks = node.cotRemarks()
+ assertTrue(remarks!!.contains("Battery: 0%"), remarks)
+ assertTrue(remarks.contains("Voltage: 0.00V"), remarks)
+ assertTrue(remarks.contains("Chan Util: 0.0%"), remarks)
+ assertTrue(remarks.contains("Air Util Tx: 0.0%"), remarks)
+ assertTrue(remarks.contains("RSSI: 0 dBm"), remarks)
+ assertTrue(remarks.contains("SNR: 0.0 dB"), remarks)
+ }
+
+ @Test
+ fun `negative fractional readings keep their sign`() {
+ // -5 / 10 truncates to 0 in integer division; naive formatting rendered -0.5 as "0.5".
+ val remarks = meshNode(snr = -0.5f).cotRemarks()
+ assertTrue(remarks!!.contains("SNR: -0.5 dB"), remarks)
+
+ val belowMinusOne = meshNode(snr = -12.5f).cotRemarks()
+ assertTrue(belowMinusOne!!.contains("SNR: -12.5 dB"), belowMinusOne)
+ }
+
+ @Test
+ fun `cot event matches the cross-platform contract`() {
+ val cot = meshNode().toCoTMessage()
+
+ assertEquals("MESHTASTIC-A1B2C3D4", cot.uid)
+ assertEquals(DEFAULT_PLI_COT_TYPE, cot.type)
+ assertEquals("WOLF - Wolf Ridge Relay", cot.contact?.callsign)
+ assertEquals(MESH_NODE_TAK_TEAM, cot.group?.name)
+ assertEquals(DEFAULT_TAK_ROLE_NAME, cot.group?.role)
+ assertEquals(76, cot.status?.battery)
+ assertEquals(MESH_NODE_STALE_MINUTES.minutes, cot.stale - cot.time)
+ assertEquals(37.7749, cot.latitude, absoluteTolerance = 1e-6)
+ assertEquals(-122.4194, cot.longitude, absoluteTolerance = 1e-6)
+ }
+
+ private companion object {
+ const val NODE_NUM = 0xa1b2c3d4.toInt()
+ const val OTHER_NODE_NUM = 0x11111111
+ const val STALE_THRESHOLD = 1_000_000
+ const val RECENT_LAST_HEARD = STALE_THRESHOLD + 1_000
+
+ fun user(
+ shortName: String = "WOLF",
+ longName: String = "Wolf Ridge Relay",
+ hwModel: org.meshtastic.proto.HardwareModel = org.meshtastic.proto.HardwareModel.TBEAM,
+ ) = org.meshtastic.proto.User(
+ id = "!a1b2c3d4",
+ short_name = shortName,
+ long_name = longName,
+ hw_model = hwModel,
+ )
+
+ fun position(latitudeI: Int = 377_749_000, longitudeI: Int = -1_224_194_000) =
+ org.meshtastic.proto.Position(latitude_i = latitudeI, longitude_i = longitudeI)
+
+ @Suppress("LongParameterList")
+ fun meshNode(
+ shortName: String = "WOLF",
+ longName: String = "Wolf Ridge Relay",
+ hwModel: org.meshtastic.proto.HardwareModel = org.meshtastic.proto.HardwareModel.TBEAM,
+ latitudeI: Int = 377_749_000,
+ longitudeI: Int = -1_224_194_000,
+ lastHeard: Int = RECENT_LAST_HEARD,
+ snr: Float = 8.5f,
+ rssi: Int = -92,
+ // Exact tenths so NumberFormatter's rounding can't introduce ambiguity here β€”
+ // rounding behavior itself belongs to NumberFormatterTest, not this class.
+ batteryLevel: Int = 76,
+ voltage: Float = 3.9f,
+ channelUtilization: Float = 12.5f,
+ airUtilTx: Float = 4.2f,
+ ) =
+ Node(
+ num = NODE_NUM,
+ user = user(shortName, longName, hwModel),
+ position = position(latitudeI, longitudeI),
+ lastHeard = lastHeard,
+ snr = snr,
+ rssi = rssi,
+ deviceMetrics =
+ org.meshtastic.proto.DeviceMetrics(
+ battery_level = batteryLevel,
+ voltage = voltage,
+ channel_utilization = channelUtilization,
+ air_util_tx = airUtilTx,
+ ),
+ )
+ }
+}

diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshToCotBroadcasterTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshToCotBroadcasterTest.kt
new file mode 100644
index 0000000000..3bef087e32
--- /dev/null
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/MeshToCotBroadcasterTest.kt
@@ -0,0 +1,242 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.takserver
+
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.testing.FakeTakPrefs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.time.Clock
+
+class MeshToCotBroadcasterTest {
+ // FakeTAKServerManager lives in its own file in this source set, shared with TAKMeshIntegrationTest.
+ // It never touches isRunning; MeshToCotBroadcaster only reads connectionCount.
+
+ private class Harness(val scope: TestScope) {
+ val serverManager = FakeTAKServerManager()
+ val nodeRepository = FakeNodeRepository()
+ val takPrefs = FakeTakPrefs()
+ private val dispatcher = UnconfinedTestDispatcher(scope.testScheduler)
+
+ val broadcaster =
+ MeshToCotBroadcaster(
+ takServerManager = serverManager,
+ nodeRepository = nodeRepository,
+ takPrefs = takPrefs,
+ dispatchers = CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher),
+ )
+
+ /** Simulates an attached TAK client; without one the broadcaster deliberately stays quiet. */
+ fun withConnectedClient() = apply { serverManager.connections.value = 1 }
+
+ fun enableMeshToCot() = apply { takPrefs.isMeshToCotEnabled.value = true }
+
+ fun setOurNodeNum(num: Int) = apply { nodeRepository.setMyNodeInfo(myNodeInfo(num)) }
+
+ /** Lets the broadcaster's inter-message spacing elapse so every queued send lands. */
+ fun settle() {
+ scope.advanceTimeBy(SETTLE_MS)
+ scope.runCurrent()
+ }
+ }
+
+ @Test
+ fun `stays silent while the preference is off`() = runTest {
+ val h = Harness(this).withConnectedClient()
+ h.nodeRepository.setNodes(listOf(meshNode()))
+
+ h.broadcaster.start(backgroundScope)
+ h.settle()
+
+ assertTrue(h.serverManager.broadcasts.isEmpty(), "opt-in pref off must publish nothing")
+ }
+
+ @Test
+ fun `publishes eligible nodes once enabled`() = runTest {
+ val h = Harness(this).withConnectedClient().enableMeshToCot()
+ h.nodeRepository.setNodes(listOf(meshNode()))
+
+ h.broadcaster.start(backgroundScope)
+ h.settle()
+
+ assertEquals(1, h.serverManager.broadcasts.size)
+ assertEquals("MESHTASTIC-A1B2C3D4", h.serverManager.broadcasts.single().uid)
+ }
+
+ @Test
+ fun `does not publish without a connected TAK client`() = runTest {
+ // Broadcasting with no client would fill TAKServerManager's 50-entry offline queue with
+ // node PLIs and evict genuine mesh CoT. The replay on connect covers the gap.
+ val h = Harness(this).enableMeshToCot()
+ h.nodeRepository.setNodes(listOf(meshNode()))
+
+ h.broadcaster.start(backgroundScope)
+ h.settle()
+
+ assertTrue(h.serverManager.broadcasts.isEmpty())
+ }
+
+ @Test
+ fun `skips ineligible nodes`() = runTest {
+ val h = Harness(this).withConnectedClient().enableMeshToCot().setOurNodeNum(OUR_NODE_NUM)
+ h.nodeRepository.setNodes(
+ listOf(
+ meshNode(num = OUR_NODE_NUM),
+ meshNode(num = 0x22222222, latitudeI = 0, longitudeI = 0),
+ meshNode(num = 0x33333333, hwModel = org.meshtastic.proto.HardwareModel.UNSET),
+ meshNode(num = 0x44444444, lastHeard = 1),
+ meshNode(num = ELIGIBLE_NODE_NUM),
+ ),
+ )
+
+ h.broadcaster.start(backgroundScope)
+ h.settle()
+
+ assertEquals(1, h.serverManager.broadcasts.size, "only the one eligible node should publish")
+ assertEquals(meshNode(num = ELIGIBLE_NODE_NUM).cotUid(), h.serverManager.broadcasts.single().uid)
+ }
+
+ @Test
+ fun `an unchanged node is not rebroadcast`() = runTest {
+ val h = Harness(this).withConnectedClient().enableMeshToCot()
+ val node = meshNode()
+ h.nodeRepository.setNodes(listOf(node))
+
+ h.broadcaster.start(backgroundScope)
+ h.settle()
+ h.nodeRepository.setNodes(listOf(node))
+ h.settle()
+
+ assertEquals(1, h.serverManager.broadcasts.size)
+ }
+
+ @Test
+ fun `a moved node is rebroadcast`() = runTest {
+ val h = Harness(this).withConnectedClient().enableMeshToCot()
+ h.nodeRepository.setNodes(listOf(meshNode()))
+
+ h.broadcaster.start(backgroundScope)
+ h.settle()
+ h.nodeRepository.setNodes(listOf(meshNode(latitudeI = 387_749_000)))
+ h.settle()
+
+ assertEquals(2, h.serverManager.broadcasts.size)
+ }
+
+ @Test
+ fun `a connecting client gets a full replay`() = runTest {
+ val h = Harness(this).withConnectedClient().enableMeshToCot()
+ h.nodeRepository.setNodes(listOf(meshNode()))
+
+ h.broadcaster.start(backgroundScope)
+ h.settle()
+ assertEquals(1, h.serverManager.broadcasts.size)
+
+ // The new client has seen none of the earlier broadcasts, so dedup must not suppress them.
+ h.serverManager.emitClientConnected()
+ h.settle()
+
+ assertEquals(2, h.serverManager.broadcasts.size)
+ }
+
+ @Test
+ fun `stationary nodes are refreshed before their markers go stale`() = runTest {
+ val h = Harness(this).withConnectedClient().enableMeshToCot()
+ h.nodeRepository.setNodes(listOf(meshNode()))
+
+ h.broadcaster.start(backgroundScope)
+ h.settle()
+ assertEquals(1, h.serverManager.broadcasts.size)
+
+ // Nothing about the node changes, but ATAK expires the marker at MESH_NODE_STALE_MINUTES.
+ advanceTimeBy(MESH_TO_COT_REFRESH_INTERVAL_MS + SETTLE_MS)
+ runCurrent()
+
+ assertTrue(h.serverManager.broadcasts.size >= 2, "stationary node must be re-sent to stay alive in ATAK")
+ assertTrue(MESH_TO_COT_REFRESH_INTERVAL_MS < MESH_NODE_STALE_MINUTES * 60L * 1000L)
+ }
+
+ @Test
+ fun `disabling the preference stops publishing`() = runTest {
+ val h = Harness(this).withConnectedClient().enableMeshToCot()
+ h.nodeRepository.setNodes(listOf(meshNode()))
+
+ h.broadcaster.start(backgroundScope)
+ h.settle()
+ val afterEnable = h.serverManager.broadcasts.size
+
+ h.takPrefs.isMeshToCotEnabled.value = false
+ h.nodeRepository.setNodes(listOf(meshNode(latitudeI = 387_749_000)))
+ h.settle()
+
+ assertEquals(afterEnable, h.serverManager.broadcasts.size)
+ }
+
+ private companion object {
+ const val OUR_NODE_NUM = 0x0badf00d
+ const val ELIGIBLE_NODE_NUM = 0xa1b2c3d4.toInt()
+ const val SETTLE_MS = 1_000L
+
+ fun recentLastHeard() = Clock.System.now().epochSeconds.toInt()
+
+ @Suppress("LongParameterList")
+ fun meshNode(
+ num: Int = ELIGIBLE_NODE_NUM,
+ latitudeI: Int = 377_749_000,
+ longitudeI: Int = -1_224_194_000,
+ lastHeard: Int = recentLastHeard(),
+ hwModel: org.meshtastic.proto.HardwareModel = org.meshtastic.proto.HardwareModel.TBEAM,
+ ) = Node(
+ num = num,
+ user =
+ org.meshtastic.proto.User(
+ id = "!" + num.toUInt().toString(16).padStart(8, '0'),
+ short_name = "WOLF",
+ long_name = "Wolf Ridge Relay",
+ hw_model = hwModel,
+ ),
+ position = org.meshtastic.proto.Position(latitude_i = latitudeI, longitude_i = longitudeI),
+ lastHeard = lastHeard,
+ )
+
+ fun myNodeInfo(num: Int) = MyNodeInfo(
+ myNodeNum = num,
+ hasGPS = false,
+ model = null,
+ firmwareVersion = "2.8.0",
+ couldUpdate = false,
+ shouldUpdate = false,
+ currentPacketId = 1L,
+ messageTimeoutMsec = 5000,
+ minAppVersion = 1,
+ maxChannels = 8,
+ hasWifi = false,
+ channelUtilization = 0f,
+ airUtilTx = 0f,
+ deviceId = null,
+ )
+ }
+}

diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
index e087dc2f12..cb095060dc 100644
--- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
@@ -17,17 +17,14 @@
package org.meshtastic.core.takserver
import co.touchlab.kermit.Severity
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
+import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
@@ -42,6 +39,7 @@ import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioSessionContext
import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.testing.FakeTakPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSet
@@ -75,42 +73,7 @@ import kotlin.time.Duration.Companion.minutes
class TAKMeshIntegrationTest {
// ── Fakes ────────────────────────────────────────────────────────────────
-
- private class FakeTAKServerManager : TAKServerManager {
- private val _isRunning = MutableStateFlow(false)
- override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
- override val connectionCount: StateFlow<Int> = MutableStateFlow(0)
-
- private val _inboundMessages = MutableSharedFlow<InboundCoTMessage>(extraBufferCapacity = 64)
- override val inboundMessages: SharedFlow<InboundCoTMessage> = _inboundMessages.asSharedFlow()
-
- val broadcasts = mutableListOf<CoTMessage>()
- val rawBroadcasts = mutableListOf<String>()
- var startCount = 0
- var stopped = false
-
- override fun start(scope: CoroutineScope) {
- startCount++
- _isRunning.value = true
- }
-
- override fun stop() {
- stopped = true
- _isRunning.value = false
- }
-
- override fun broadcast(cotMessage: CoTMessage) {
- broadcasts.add(cotMessage)
- }
-
- override fun broadcastRawXml(xml: String) {
- rawBroadcasts.add(xml)
- }
-
- suspend fun emitInbound(cotMessage: CoTMessage, clientInfo: TAKClientInfo? = null) {
- _inboundMessages.emit(InboundCoTMessage(cotMessage, clientInfo))
- }
- }
+ // FakeTAKServerManager lives in its own file in this source set, shared with MeshToCotBroadcasterTest.
private class FakeCommandSender : CommandSender {
val sentPackets = mutableListOf<DataPacket>()
@@ -343,7 +306,13 @@ class TAKMeshIntegrationTest {
val serviceRepository: FakeServiceRepository = FakeServiceRepository(),
val meshConfigHandler: FakeMeshConfigHandler = FakeMeshConfigHandler(),
val nodeRepository: FakeNodeRepository = FakeNodeRepository(),
+ val takPrefs: FakeTakPrefs = FakeTakPrefs(),
+ val dispatchers: CoroutineDispatchers =
+ UnconfinedTestDispatcher().let { CoroutineDispatchers(io = it, main = it, default = it) },
) {
+ // Mesh-to-CoT is opt-in and FakeTakPrefs defaults it off, so it stays inert here.
+ val broadcaster = MeshToCotBroadcaster(serverManager, nodeRepository, takPrefs, dispatchers)
+
val integration =
TAKMeshIntegration(
takServerManager = serverManager,
@@ -351,6 +320,7 @@ class TAKMeshIntegrationTest {
serviceRepository = serviceRepository,
meshConfigHandler = meshConfigHandler,
nodeRepository = nodeRepository,
+ meshToCotBroadcaster = broadcaster,
)
}

diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
index 0834a03c22..2373c0bc7c 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt
@@ -475,4 +475,10 @@ class FakeTakPrefs : org.meshtastic.core.repository.TakPrefs {
override fun setTakServerEnabled(enabled: Boolean) {
isTakServerEnabled.value = enabled
}
+
+ override val isMeshToCotEnabled = MutableStateFlow(false)
+
+ override fun setMeshToCotEnabled(enabled: Boolean) {
+ isMeshToCotEnabled.value = enabled
+ }
}

diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts
index 7096fb7cfd..3bd0ab5d11 100644
--- a/feature/settings/build.gradle.kts
+++ b/feature/settings/build.gradle.kts
@@ -52,6 +52,7 @@ kotlin {
commonTest.dependencies {
implementation(projects.core.datastore)
+ implementation(projects.core.testing)
implementation(libs.compose.multiplatform.ui.test)
}

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
index e924b7c226..b8c98d97a8 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt
@@ -62,6 +62,8 @@ import org.meshtastic.core.resources.tak_server_enabled
import org.meshtastic.core.resources.tak_server_enabled_desc
import org.meshtastic.core.resources.tak_server_export_data_package_desc
import org.meshtastic.core.resources.tak_server_loading
+import org.meshtastic.core.resources.tak_server_mesh_to_cot
+import org.meshtastic.core.resources.tak_server_mesh_to_cot_desc
import org.meshtastic.core.resources.tak_server_section
import org.meshtastic.core.resources.tak_server_test_card_title
import org.meshtastic.core.resources.tak_server_test_idle
@@ -164,20 +166,27 @@ internal fun TakConfigCard(
// ── TAK Server Screen (Settings β†’ Advanced) ─────────────────────────────────
// App-local TAK server controls: enable/disable, export data package, debug test harness.
+/**
+ * Extracted from [TakServerScreen]'s [TakPermissionHandler] callback so tests can drive the exact function production
+ * calls, rather than duplicating its conditional.
+ */
+internal fun handleTakPermissionResult(granted: Boolean, isTakServerEnabled: Boolean, takPrefs: TakPrefs) {
+ if (!granted && isTakServerEnabled) {
+ takPrefs.setTakServerEnabled(false)
+ }
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TakServerScreen(onBack: () -> Unit) {
val takPrefs: TakPrefs = koinInject()
val isTakServerEnabled by takPrefs.isTakServerEnabled.collectAsStateWithLifecycle()
+ val isMeshToCotEnabled by takPrefs.isMeshToCotEnabled.collectAsStateWithLifecycle()
val exportLauncher = rememberDataPackageExporter { TAKDataPackageGenerator.generateDataPackage() }
TakPermissionHandler(
isTakServerEnabled = isTakServerEnabled,
- onPermissionResult = { granted ->
- if (!granted && isTakServerEnabled) {
- takPrefs.setTakServerEnabled(false)
- }
- },
+ onPermissionResult = { granted -> handleTakPermissionResult(granted, isTakServerEnabled, takPrefs) },
)
Scaffold(
@@ -209,6 +218,8 @@ fun TakServerScreen(onBack: () -> Unit) {
TakServerSection(
isTakServerEnabled = isTakServerEnabled,
onEnabledChange = { takPrefs.setTakServerEnabled(it) },
+ isMeshToCotEnabled = isMeshToCotEnabled,
+ onMeshToCotChange = { takPrefs.setMeshToCotEnabled(it) },
onExport = { exportLauncher("Meshtastic_TAK_Server.zip") },
)
TakMeshTestCard()
@@ -218,7 +229,13 @@ fun TakServerScreen(onBack: () -> Unit) {
/** Stateless TAK server enable/disable section β€” previewable without DI. */
@Composable
-internal fun TakServerSection(isTakServerEnabled: Boolean, onEnabledChange: (Boolean) -> Unit, onExport: () -> Unit) {
+internal fun TakServerSection(
+ isTakServerEnabled: Boolean,
+ onEnabledChange: (Boolean) -> Unit,
+ isMeshToCotEnabled: Boolean,
+ onMeshToCotChange: (Boolean) -> Unit,
+ onExport: () -> Unit,
+) {
TitledCard(title = stringResource(Res.string.tak_server_section)) {
SwitchPreference(
title = stringResource(Res.string.tak_server_enabled),
@@ -228,6 +245,14 @@ internal fun TakServerSection(isTakServerEnabled: Boolean, onEnabledChange: (Boo
onCheckedChange = onEnabledChange,
)
if (isTakServerEnabled) {
+ HorizontalDivider()
+ SwitchPreference(
+ title = stringResource(Res.string.tak_server_mesh_to_cot),
+ summary = stringResource(Res.string.tak_server_mesh_to_cot_desc),
+ checked = isMeshToCotEnabled,
+ enabled = true,
+ onCheckedChange = onMeshToCotChange,
+ )
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt
index 265b5fed72..f969105dcf 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPreviews.kt
@@ -42,13 +42,29 @@ fun TakConfigCardPreview() {
@PreviewLightDark
@Composable
fun TakServerSectionDisabledPreview() {
- AppTheme { TakServerSection(isTakServerEnabled = false, onEnabledChange = {}, onExport = {}) }
+ AppTheme {
+ TakServerSection(
+ isTakServerEnabled = false,
+ onEnabledChange = {},
+ isMeshToCotEnabled = false,
+ onMeshToCotChange = {},
+ onExport = {},
+ )
+ }
}
@PreviewLightDark
@Composable
fun TakServerSectionEnabledPreview() {
- AppTheme { TakServerSection(isTakServerEnabled = true, onEnabledChange = {}, onExport = {}) }
+ AppTheme {
+ TakServerSection(
+ isTakServerEnabled = true,
+ onEnabledChange = {},
+ isMeshToCotEnabled = true,
+ onMeshToCotChange = {},
+ onExport = {},
+ )
+ }
}
@PreviewLightDark

diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPermissionDeniedTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPermissionDeniedTest.kt
index bb8ba8fbe1..deb47fb9d2 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPermissionDeniedTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigPermissionDeniedTest.kt
@@ -17,10 +17,8 @@
package org.meshtastic.feature.settings.radio.component
import app.cash.turbine.test
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.runTest
-import org.meshtastic.core.repository.TakPrefs
+import org.meshtastic.core.testing.FakeTakPrefs
import kotlin.test.Test
import kotlin.test.assertFalse
@@ -30,32 +28,22 @@ import kotlin.test.assertFalse
* Verifies that when ACCESS_LOCAL_NETWORK permission is denied on Android 17+, the TAK server is disabled (not crashed)
* and the UI reflects the disabled state.
*
- * The actual UI composition test requires a Compose test rule, but the behavioral contract can be validated at the
- * state level: when the permission handler reports denial while the server is enabled, setTakServerEnabled(false) is
- * called.
+ * The actual UI composition test requires a Compose test rule, but these drive the exact function
+ * [handleTakPermissionResult] that [TakServerScreen]'s [TakPermissionHandler] callback calls, rather than duplicating
+ * its conditional β€” so a change to that production logic fails these tests instead of passing silently.
*/
class TAKConfigPermissionDeniedTest {
- /** Minimal TakPrefs that tracks calls to setTakServerEnabled. */
- private class FakeTakPrefs : TakPrefs {
- private val _isTakServerEnabled = MutableStateFlow(true)
- override val isTakServerEnabled: StateFlow<Boolean> = _isTakServerEnabled
-
- override fun setTakServerEnabled(enabled: Boolean) {
- _isTakServerEnabled.value = enabled
- }
- }
-
@Test
fun `permission denied disables TAK server`() = runTest {
val prefs = FakeTakPrefs()
+ prefs.isTakServerEnabled.value = true // Scenario starts with the server already enabled.
- // Simulate the exact logic from TAKConfigItemList.kt:
- // onPermissionResult = { granted -> if (!granted && isTakServerEnabled) takPrefs.setTakServerEnabled(false) }
- val granted = false
- if (!granted && prefs.isTakServerEnabled.value) {
- prefs.setTakServerEnabled(false)
- }
+ handleTakPermissionResult(
+ granted = false,
+ isTakServerEnabled = prefs.isTakServerEnabled.value,
+ takPrefs = prefs,
+ )
prefs.isTakServerEnabled.test { assertFalse(awaitItem()) }
}
@@ -65,11 +53,12 @@ class TAKConfigPermissionDeniedTest {
val prefs = FakeTakPrefs()
prefs.setTakServerEnabled(false) // Already disabled
- // Simulate permission denied β€” should not crash or throw
- val granted = false
- if (!granted && prefs.isTakServerEnabled.value) {
- prefs.setTakServerEnabled(false)
- }
+ // Should not crash or throw
+ handleTakPermissionResult(
+ granted = false,
+ isTakServerEnabled = prefs.isTakServerEnabled.value,
+ takPrefs = prefs,
+ )
prefs.isTakServerEnabled.test {
assertFalse(awaitItem()) // Still false, no crash
@@ -79,12 +68,9 @@ class TAKConfigPermissionDeniedTest {
@Test
fun `permission granted does not disable TAK server`() = runTest {
val prefs = FakeTakPrefs()
+ prefs.isTakServerEnabled.value = true // Scenario starts with the server already enabled.
- // Simulate permission granted
- val granted = true
- if (!granted && prefs.isTakServerEnabled.value) {
- prefs.setTakServerEnabled(false)
- }
+ handleTakPermissionResult(granted = true, isTakServerEnabled = prefs.isTakServerEnabled.value, takPrefs = prefs)
prefs.isTakServerEnabled.test {
// Server should still be enabled

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png
index 3b6e876b71..4887426655 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Dark_d19fbf1f_0.png differ

diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png
index c6271875d5..e2e94408cc 100644
Binary files a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotTakServerSectionEnabled_Light_b29dc7a7_0.png differ

Served by rngit 1.4.2 - Generated in 0.55s